Ver licença de uso para detalhes.
Prefácio
Esse tutorial visa apresentar alguns conceitos de processamento digital de imagens usando a biblioteca de visão artificial OpenCV. Foi concebido como material acessório da disciplina processamento digital de imagens e, neste contexto, assume que o leitor possui fundamentação teórica suficiente para acompanhar as lições. Dominar adequadamente conceitos de programação em C++ e da matemática explorada em cursos de Análise de Sinais e Sistemas são requiinclude::dft.asc[] serão exploradas por enquanto.
Toda e qualquer sugestão e/ou contribuição visando melhorar e evoluir este tutorial será bemvinda. Pode mandá-la diretamente via e-mail para ambj@dca.ufrn.br
Os exemplos descritos no tutorial foram desenvolvidos usando a API C++ do OpenCV. Foram testados em um ambiente executando sistema operacional Linux, mas devem funcionar corretamente em outras plataformas.
Parte I: Processamento de Imagens no Domínio Espacial
1. Conceitos iniciais
1.1. O que é OpenCV
OpenCV (Open Source Computer Vision Library: http://www.opencv.org) é uma biblioteca (ou conjunto de bibliotecas) disponível para algumas linguagens de programação que visa oferecer um vasto ferramental para tratamento de imagens, visão computacional e reconhecimento de padrões.
A biblioteca é organizada na forma de módulos, cada um agregando um conjunto de funções. Entre os módulos disponíveis pela biblioteca, destacam-se:
-
Estruturas de núcleo, como tipos de dados comuns, matrizes e vetores, para armazenamento de informações de forma conveniente para os demais módulos.
-
Processamento de imagens, para realizar transformações geométricas, filtragem linear e não linear, tratamento de cor, entre outras coisas.
-
Vídeo, para avaliação de movimento (ex: análise de fluxo ótico) e rastreio de objetos.
-
Calibração de câmeras.
-
Extração de características.
-
Deteção de objetos.
-
Highgui, para tratamento facilitado de criação de interfaces gráficas.
Neste tutorial, alguns desses módulos serão explorados na forma de exemplos e exercícios de fixação.
Detalhes acerca da instalação da biblioteca OpenCV não são descritos aqui. Cada sistema operacional possui uma forma de instalação distinta, que pode ser consultatada no quickstart oferecido no website do projeto.
1.2. Hello, OpenCV
O primeiro exemplo que será apresentado visa mostrar como compilar e executar um pequeno usando OpenCV em um ambiente Linux. Em suma, será necessário o arquivo contendo o código-fonte a ser compilado e um arquivo contendo regras de compilação na forma de um Makefile.
As tarefas de compilação foram automatizadas com o utilitário make. O make determina automaticamente que partes de um grande programa necessitam ser recompiladas e os comandos necessários para recompilá-las, a partir da leitura das regras definidas em um arquivo Makefile. Assim, para efetuar a compilação do programa, basta executar o comando make, ao invés de digitar dezenas de comandos no prompt do Unix.
O arquivo da Listagem 1 da foi utilizado para compilar os exemplos desse curso. Para obter uma cópia deste arquivo clique aqui. Veja que o arquivo utilizado prevê o uso da biblioteca opencv 4.0. Caso esteja utilizando alguma biblioteca mais antiga, subsititua o parâmetro opencv4 por apenas opencv e repita a compilação
.SUFFIXES:
.SUFFIXES: .cpp
GCC = g++
.cpp:
$(GCC) -Wall -Wunused -std=c++11 -O2 $< -o $@ `pkg-config --cflags --libs opencv4`
As regras contidas neste arquivo Makefile incluem opções de
compilação para incluir as dependências da biblioteca OpenCV para o
programa que será compilado. Este Makefile possibilita que programas
simples possam ser facilmente compilados. Como foi testado em um
sistema Linux, o arquivo poderá carecer de modificações em outros
sistemas.
O seu ambiente de desenvolvimento poderá ser testado com programa exemplos/hello.cpp , mostrada Listagem Hello, cuja única funcionalidade apresentar na tela uma imagem fornecida via linha de comando.
#include <iostream>
#include <opencv2/opencv.hpp>
int main(int argc, char** argv){
cv::Mat image;
image = cv::imread(argv[1],cv::IMREAD_GRAYSCALE);
cv::imshow("image", image);
cv::waitKey();
return 0;
}
Para compilar e executar o programa hello.cpp, salve-o juntamente com o arquivo Makefile e a imagem biel.png em um diretório e execute a seguinte seqüência de comandos:
$ make hello
$ ./hello biel.png
A saída do programa hello é mostrado na Figura 1
Caso o programa funcione conforme o exemplo, provavelmente seu ambiente de testes estará operacional para os exemplos do tutorial.
Neste tutorial, geralmente imagens armazenada no formato PNG serão usadas. Este formato suporta representação de imagens de diversas formas, como em tons de cinza, coloridas e preto-e-branco. Imagens em outros formatos tais como JPEG podem oferecer algumas limitações para os casos que serão abordados. Por exemplo, arquivos JPEG armazenam apenas imagens em formato colorido e, durante a o processo de gravação das imagens, o algoritmo de compressão com perdas pode modificar o conteúdo da imagem original a ser gravada.
2. Manipulando pixels em uma imagem
O objetivo dessa lição é mostrar como manipular os pixels de uma imagem, mudando a cor de uma pequena região retangular de uma imagem fornecida para processamento.
Alguns conceitos importantes serão abordados neste contexto:
-
Alguns tipos de dados comuns mais usados no OpenCV.
-
Funções para realizar entrada/saída de dados.
-
Funções para acessar os pixels de uma imagem.
-
Funções para realizar interações na interface gráfica.
Para evoluir nesses conceitos, realize o download do programa
pixels.cpp, mostrado na Listagem
Pixels, e a imagem bolhas.png. Salve
ambos os arquivos num diretório, juntamente que contém arquivo Makefile. O programa irá abrir a
imagem bolhas.png (interpretando-a em escala de cinza), deverá
exibi-la em uma janela e desenhar um quadrado preto em uma região
pré-estabelecida.
Após isso, ele irá aguardar que o usuário pressione alguma tecla. Uma vez pressionada a tecla, o programa reabrirá o arquivo da imagem interpretando-a em escala de cores e passará a desenhar um quadrado vermelho na mesma região que foi pré-estabelecida.
#include <iostream>
#include <opencv2/opencv.hpp>
int main(int, char**){
cv::Mat image;
cv::Vec3b val;
image= cv::imread("bolhas.png",cv::IMREAD_GRAYSCALE);
if(!image.data)
std::cout << "nao abriu bolhas.png" << std::endl;
cv::namedWindow("janela", cv::WINDOW_AUTOSIZE);
for(int i=200;i<210;i++){
for(int j=10;j<200;j++){
image.at<uchar>(i,j)=0;
}
}
cv::imshow("janela", image);
cv::waitKey();
image= cv::imread("bolhas.png",cv::IMREAD_COLOR);
val[0] = 0; //B
val[1] = 0; //G
val[2] = 255; //R
for(int i=200;i<210;i++){
for(int j=10;j<200;j++){
image.at<cv::Vec3b>(i,j)=val;
}
}
cv::imshow("janela", image);
cv::waitKey();
return 0;
}
Para compilar e executar o programa pixels.cpp, salve-o juntamente com o arquivo Makefile em um diretório e execute a seguinte seqüência de comandos:
$ make pixels
$ ./pixels
A saída do programa pixels é mostrado na Figura 2
2.1. Descrição do programa pixels.cpp
#include <iostream>
#include <opencv2/opencv.hpp>
Em geral, a maior parte das funcionalidades da biblioteca é definida
no arquivo de cabeçalho opencv.hpp. É necessário procurar na árvore
do compilador usado este arquivo para que seu caminho seja incluído no
código.
Nesse arquivo, são definidos tipos básicos da biblioteca, bem como os protótipos de várias funções usadas para tratamento de imagens. Ele agrega as definições de funções que são usadas para entrada e saída de dados, criação de e manipulação de janelas e seus eventos, além de widgets para permitir interação com o usuário.
cv::Mat image;
A interface em C++ do OpenCV provê um tipo básico de estrutura para
armazenar imagems: a classe Mat. Dependendo da forma como é criado,
um objeto dessa classe é capaz de armazenar imagens (matrizes) de
diversos tipos diferentes, tais como inteiros, floats, doubles, etc.
Dezenas de métodos são providos para essa classe, métodos estes que
serão apresentados ao longo do tutorial, conforme a demanda se
dê. Nesta lição, apenas o método at será utilizado.
Outros tipos também são predefinidos no OpenCV, tal como o tipo
Vec3b. A classe Vec é definida no OpenCV para abrigar diversas
formas de vetores curtos. A classe é definida por gabaritos e provê
armazenamento de uma quantidade de valores de um dado tipo fornecido
na instanciação do gabarito.
Para ilustrar o uso da classe Vec, são mostrados em seguida, alguns
dos tipos predefinidos internamente no OpenCV.
typedef Vec<uchar, 2> Vec2b;
typedef Vec<uchar, 3> Vec3b;
typedef Vec<uchar, 4> Vec4b;
typedef Vec<short, 2> Vec2s;
typedef Vec<short, 3> Vec3s;
typedef Vec<short, 4> Vec4s;
typedef Vec<int, 2> Vec2i;
typedef Vec<int, 3> Vec3i;
typedef Vec<int, 4> Vec4i;
typedef Vec<float, 2> Vec2f;
typedef Vec<float, 3> Vec3f;
typedef Vec<float, 4> Vec4f;
typedef Vec<float, 6> Vec6f;
typedef Vec<double, 2> Vec2d;
typedef Vec<double, 3> Vec3d;
typedef Vec<double, 4> Vec4d;
typedef Vec<double, 6> Vec6d;
Neste caso, o tipo Vec3b usado no exemplo representa um vetor de três
componentes, cada uma do tipo unsigned char, ocupando apenas um
byte na memória.
image= cv::imread("bolhas.png",cv::IMREAD_GRAYSCALE);
if(!image.data)
std::cout << "nao abriu bolhas.png" << std::endl;
Esse trecho de código usa a função imread() para ler uma imagem
presente em um arquivo e armazená-la no objeto image. A função
imread() recebe dois parâmetros: o primeiro é o caminho para o arquivo
a ser aberto; o segundo é a forma como a imagem será
interpretada. Neste casso, independentemente do formato da imagem
presente no arquivo bolhas.png, ela será imediatamente transformada
em uma imagem em tons de cinza antes de ser guardada no objeto
image.
Caso o arquivo não seja aberto (ex: o arquivo não foi encontrado, ou o
usuário não possui permissão de leitura ativado), o campo data do
objeto image deverá ter valor nulo, sinalizando o erro.
Algo importante a ser observado nesse momento é que, embora a
estrutura para armazenamento de imagens seja provida por uma classe -
a classe Mat - algumas das estruturas internas são públicas. Isso
ajuda a não haver degradação de desempenho com o uso excessivo de
chamadas de métodos.
std::namedWindow("janela", cv::WINDOW_AUTOSIZE);
Cria uma janela para que o usuário possa referenciá-la pelo nome que é
fornecido. O parâmetro WINDOW_AUTOSIZE permitirá que a janela se
ajuste automaticamente para o tamanho da imagen que for fornecida para
exibição.
for(int i=200;i<210;i++){
for(int j=10;j<200;j++){
image.at<uchar>(i,j)=0;
}
}
Este trecho de código desenha um retângulo preto na imagem. Neste caso, assumindo que a primeira dimensão representa a coordenada x e a segunda dimensão da matriz representa a coordenada y, será desenhado um retângulo do ponto \$(200,10)\$ até o ponto \$(210,200)\$.
Os pixels da região são acessados ou modificados com o método
at. Observe que este método sofre o efeito de um gabarito, que deve
receber um tipo correspondente o tipo de dado que está armazenado no
objeto image. Como a leitura foi feita assumindo uma imagem em tons
de cinza, o tipo de dado necessário será, neste caso, unsigned
char. Tipos diferentes poderão gerar resultados incorretos, posto que
a função at() interpretará a sequência de bytes da matriz image de
forma inapropriada.
Para manter os sistema referencial do tipo destrógiro, assume-se que os eixos x e y ficarão organizados conforme apresenta a Figura 3, com a origem no canto superior esquerdo da imagem.
cv::imshow("janela", image);
cv::waitKey();
A imagem image é mostrada na janela "janela" e o programa aguarda
até que o usuário digite alguma tecla.
image= cv::imread("bolhas.png", cv::IMREAD_COLOR);
O procedimento que segue repete os passos anteriores, só que agora a
imagem interpretada com três componentes de cor. A matriz image
agora guardará, para cada pixel, um conjunto de 3 bytes para armazenar
as contribuições de vermelho, verde e amarelo que este possui.
val[0] = 0; //B
val[1] = 0; //G
val[2] = 255; //R
As cores em um pixel são ordenadas na sequência B→G→R (Azul, Verde,
Vermelho), podendo os pixels da matriz serem interpretados como um
conjunto de elementos do tipo Vec3b.
for(int i=200;i<210;i++){
for(int j=10;j<200;j++){
image.at<Vec3b>(i,j)=val;
}
}
Agora, a função at() interpreta a sequência de bytes usada para
armazenar a matriz image como sendo formadas por pixels do tipo
Vec3b, sendo às posições correspondentes atribuídas a cor vermelha
predefinida na variável val.
2.2. Exercícios
-
Utilizando o programa exemplos/pixels.cpp como referência, implemente um programa
regions.cpp. Esse programa deverá solicitar ao usuário as coordenadas de dois pontos \$P_1\$ e \$P_2\$ localizados dentro dos limites do tamanho da imagem e exibir que lhe for fornecida. Entretanto, a região definida pelo retângulo de vértices opostos definidos pelos pontos \$P_1\$ e \$P_2\$ será exibida com o negativo da imagem na região correspondente. O efeito é ilustrado na Figura 4.
-
Utilizando o programa exemplos/pixels.cpp como referência, implemente um programa
trocaregioes.cpp. Seu programa deverá trocar os quadrantes em diagonal na imagem. Explore o uso da classeMate seus construtores para criar as regiões que serão trocadas. O efeito é ilustrado na Figura 5.
3. Decomposição de imagens em planos de bits
Com apenas 8 bits é possível representar cada componente de cor em uma imagem em uma faixa de variação de 0 a 255. Apesar ter apenas um byte de tamanho, essa quantidade permite enganar com maestria o olho humano e ainda possibilita uma gama de aproximadamente 16 milhões de tonalidades de cores para compor uma imagem.
Os bits mais significativos dos pixels de uma imagem guardam as informações mais importantes para a composição da cor, ao passo que menos significativos pouca informação detém para olhos medianos. Observe, por exemplo, a sequência de imagens na Figura 6. Ela apresenta os planos de bits da imagem biel.png, onde cada uma mostra valores iguais a 0 ou 255, ou seja, são imagens monocromáticas com um bit por pixel. A imagem do canto superior esquerdo mostra o plano de bits menos significativo, enquanto a imagem do canto inferior direito mostra o plano de bits mais significativo. Perceba que nos planos de bits menos significativos pouca informação sobre a imagem é revelada, enquanto nos planos de bits mais significativos a imagem é revelada com mais detalhes.
A imagem seguinte foi composta manipulando os bits de cada pixel de forma que os N bits menos significativos de cada componente de cor fossem deixados com valores iguais a zero para seis valores distintos de N, variando de 0 (canto superior esquerdo) a 7 (canto inferior direito).
Perceba como ocorre a degradação das cores da imagem na medida em que os bits são descartados. Para a imagem do exemplo, a degradação começa a ser percebida com a perda de 3 ou 4 bits menos significativos. É justamente aí que entra a possibilidade de usar os bits menos significativos da figura para ocultar informação, posto que sua influência geralmente não é perceptível na imagem.
3.1. Esteganografia em imagens digitais
Esteganografia é uma área da criptologia que se ocupa de ocultar uma informação em outra, de sorte a tornar despercebida uma determinada mensagem. Ela pode ser feita no computador usando arquivos de texto, imagens ou vídeos, de modo que apenas o receptor que conhece como a ocultação foi realizada saiba como recuperar a informação inserida. Na esteganografia, usa-se o princípio da ocultação por obscuridade, onde pressupõe-se que apenas o remetente e o destinatário sabem como decifrar o segredo enviado.
Embora esteja um desuso pelos algoritmos modernos de criptografia, ainda é possível se divertir um pouco com isso, combinando esteganografia com imagens digitais. A ideia é esconder uma imagem secreta em outra (imagem portadora), mas sem alterar significativamente a aparência da portadora.
Descartando-se uma quantidade de bits menos significativos de cada pixel, suficiente para não perder a qualidade visual da imagem, pode-se usar posições dos bits perdidos para esconder informação. Dá-se a essa prática o nome de esteganografia de bits menos significativos, ou Least Significant Bit steganography.
A ideia é esconder a imagem biel.jpg na imagem sushi.jpg, de modo que a imagem resultante não apresente diferenças significativas em relação à imagem portadora.
Para isso, serão preservados os 5 bits mais significativos (MSB) dos pixels da imagem portadora e os 3 bits mais significativos da imagem escondida serão colocados no lugar dos 3 bits menos significativos (LSB) da imagem portadora, como ilustra a Figura 9. É importante observar que ambas as imagem escondida deve ter no máximo o tamanho da imagem portadora.
Na Listagem 4 que segue é mostrado como esconder o conteúdo de uma imagem em outra utilizando operadores de manipulação de bits com OpenCV.
#include <iostream>
#include <opencv2/opencv.hpp>
int main(int argc, char**argv) {
cv::Mat imagemPortadora, imagemEscondida, imagemFinal;
cv::Vec3b valPortadora, valEscondida, valFinal;
int nbits = 3;
imagemPortadora = cv::imread(argv[1], cv::IMREAD_COLOR);
imagemEscondida = cv::imread(argv[2], cv::IMREAD_COLOR);
if (imagemPortadora.empty() || imagemEscondida.empty()) {
std::cout << "imagem nao carregou corretamente" << std::endl;
return (-1);
}
if (imagemPortadora.rows != imagemEscondida.rows ||
imagemPortadora.cols != imagemEscondida.cols) {
std::cout << "imagens devem ter o mesmo tamanho" << std::endl;
return (-1);
}
imagemFinal = imagemPortadora.clone();
for (int i = 0; i < imagemPortadora.rows; i++) {
for (int j = 0; j < imagemPortadora.cols; j++) {
valPortadora = imagemPortadora.at<cv::Vec3b>(i, j);
valEscondida = imagemEscondida.at<cv::Vec3b>(i, j);
valPortadora[0] = valPortadora[0] >> nbits << nbits;
valPortadora[1] = valPortadora[1] >> nbits << nbits;
valPortadora[2] = valPortadora[2] >> nbits << nbits;
valEscondida[0] = valEscondida[0] >> (8-nbits);
valEscondida[1] = valEscondida[1] >> (8-nbits);
valEscondida[2] = valEscondida[2] >> (8-nbits);
valFinal[0] = valPortadora[0] | valEscondida[0];
valFinal[1] = valPortadora[1] | valEscondida[1];
valFinal[2] = valPortadora[2] | valEscondida[2];
imagemFinal.at<cv::Vec3b>(i, j) = valFinal;
}
}
imwrite("esteganografia.png", imagemFinal);
return 0;
}
Para compilar e executar o programa esteg-encode.cpp, salve-o juntamente com os arquivo Makefile e a imagem biel.png em um diretório e execute a seguinte seqüência de comandos:
$ make esteg-encode
$ ./esteg-encode sushi.jpg biel.jpg
3.2. Descrição do programa
valPortadora[0] = valPortadora[0] >> nbits << nbits;
valEscondida[0] = valEscondida[0] >> (8-nbits);
valFinal[0] = valPortadora[0] | valEscondida[0];
A primeira linha desse trecho faz com que os N bits menos significativos da imagem portadora sejam anulados, onde N é o número de bits que serão usados para esconder a imagem codificada. A segunda linha faz com que os N bits mais significativos da imagem codificada sejam deslocados à direita para a posição dos N bits menos significativos na variável. A terceira linha faz a combinação dos valores das duas imagens, de modo que os N bits menos significativos da imagem portadora sejam substituídos pelos N bits mais significativos da imagem codificada.
A imagem resultante da esteganografia será gravada no arquivo esteganografia.png. O resultado da esteganografia é mostrado na Figura 10. Observe que a imagem resultante não apresenta diferenças visuais significativas em relação à imagem portadora.
A decomposição em planos de bits da imagem resultante da esteganografia mostra que os bits menos significativos da imagem portadora foram substituídos pelos bits mais significativos da imagem codificada, conforme mostra a Figura 11. A imagem do canto superior esquerdo mostra o plano de bits menos significativo, enquanto a imagem do canto inferior direito mostra o plano de bits mais significativo.
3.3. Exercícios
-
Usando o programa esteg-encode.cpp como referência para esteganografia, escreva um programa que recupere a imagem codificada de uma imagem resultante de esteganografia. Lembre-se que os bits menos significativos dos pixels da imagem fornecida deverão compor os bits mais significativos dos pixels da imagem recuperada. O programa deve receber como parâmetros de linha de comando o nome da imagem resultante da esteganografia. Teste a sua implementação com a imagem da Figura 12.
4. Preenchendo regiões
Uma tarefa bastante comum em processamento de imagens e visão artificial é contar a quantidade de objetos presentes em uma cena.
Para contar os objetos é necessário identificar os aglomerados de pixels associados a cada um. Neste exemplo, assume-se que a imagem é do tipo binária, ou seja, cada pixel assume apenas dois valores - 0 ou 255 - indicando que o pixel pertence ao fundo da imagem ("0") ou a algum objeto presente ("255"). Assume-se também que cada aglomerado de pixels será interpretado como um objeto individual. Esse é o processo mais comum para operações de contagem de objetos em uma imagem.
Uma das maneiras de identificar as regiões de forma única é através de rotulação. A rotulação de regiões é o processo pelo qual regiões com características comuns recebem um identificador comum (rótulo).
Em geral, um algoritmo de rotulação de imagens binárias recebe como entrada uma imagem binária e fornece como saída uma imagem em tons de cinza, com as várias regiões representativas de objetos rotuladas com um tom de cinza diferente.
No exemplo dessa lição será mostrado como rotular uma imagem binária, utilizando o algoritmo floodfill (ou seedfill) para descobrir os aglomerados de pixels. A imagem usada para teste será a presente no arquivo bolhas.png mostrada na Figura Bolhas.
O programa de referência utilizado para essa tarefa, labeling.cpp, é mostrado na Listagem Labeling.
#include <iostream>
#include <opencv2/opencv.hpp>
using namespace cv;
int main(int argc, char** argv) {
cv::Mat image, realce;
int width, height;
int nobjects;
cv::Point p;
image = cv::imread(argv[1], cv::IMREAD_GRAYSCALE);
if (!image.data) {
std::cout << "imagem nao carregou corretamente\n";
return (-1);
}
width = image.cols;
height = image.rows;
std::cout << width << "x" << height << std::endl;
p.x = 0;
p.y = 0;
// busca objetos presentes
nobjects = 0;
for (int i = 0; i < height; i++) {
for (int j = 0; j < width; j++) {
if (image.at<uchar>(i, j) == 255) {
// achou um objeto
nobjects++;
// para o floodfill as coordenadas
// x e y são trocadas.
p.x = j;
p.y = i;
// preenche o objeto com o contador
cv::floodFill(image, p, nobjects);
}
}
}
std::cout << "a figura tem " << nobjects << " bolhas\n";
cv::imshow("image", image);
cv::imwrite("labeling.png", image);
cv::waitKey();
return 0;
}
Para compilar e executar o programa labeling.cpp, salve-o juntamente com os arquivo Makefile e bolhas.png em um diretório e execute a seguinte seqüência de comandos:
$ make labeling
$ ./labeling bolhas.png
A saída do programa labeling é mostrado na Figura Labeling
4.1. Descrição do programa labeling.cpp
cv::Point p;
A estrutura Point define um ponto na segunda dimensão que permite
acesso às suas coordenadas x e y. Ele será usado no exemplo para
indicar a semente de preenchimento que é usada pelo algoritmo floodfill.
image = cv::imread(argv[1],cv::IMREAD_GRAYSCALE);
Independentemente do formato da imagem de entrada, ela será convertida para tons de cinza, uma vez que o exemplo assume essa condição.
p.x=0;
p.y=0;
Nesta fase tem início o processo de rotulação das várias regiões da imagem. Assumindo que os pixels do objeto possuem tom de cinza igual a 255, o algoritmo percorre toda a imagem, linha após linha, de cima a baixo, da esquerda para direita por pixels que tenham tom igual a 255.
Quando um elemento da matriz é encontrado com tom de cinza igual a 255, o algoritmo floodfill é executado utilizando as coordenadas desse ponto como semente.
A operação do algoritmo floodfill é bem simples: dado um ponto semente, o algoritmo sai procurando os 4- ou 8-vizinhos desse ponto (conforme configuração estabelecida) que possuem a mesma propriedade do ponto semente (geralmente o tom de cinza). Para cada ponto encontrado, muda-se sua propriedade para uma nova propriedade fornecida. Para cada ponto encontrado, também, realiza-se a busca de vizinhança para os seus 4- ou 8-vizinhos que contenham a mesma propriedade da semente. Esse processo é repetido até que não restem mais pontos com propriedade alterada na componente conectada (ou região conectada).
nobjects=0;
Inicia a contagem de objetos (inicialmente, zero objetos estão presentes)
for(int i=0; i<height; i++){
for(int j=0; j<width; j++){
if(image.at<uchar>(i,j) == 255){
nobjects++;
p.x=j;
p.y=i;
cv::floodFill(image,p,nobjects);
}
}
}
A contagem funciona percorrendo as linhas e colunas da matriz image
em busca de elementos com tom de cinza igual a 255 (pixel de
objeto). Quando encontrado, incrementa-se o contador de objeto e
executa-se o algoritmo floodfill na imagem utilizando o pixel
encontrado como semente. Observe que a região à qual o pixel pertence
será rotulada com tom de cinza igual ao número de contagem de objetos
atual.
O processo continua até que toda a imagem tenha sido rotulada.
cv::imshow("image", image);
cv::imwrite("labeling.png", image);
cv::waitKey();
Finalmente, a imagem image é mostrada (já completamente rotulada) e
então gravada no arquivo labeling.png. Uma das linhas com o comando
imshow é usada apenas para mostrar a imagem com um pouco de realce
(para fins de melhor visualização). Como esse efeito funciona será
discutido mais adiante.
4.2. Exercícios
-
Observando-se o programa labeling.cpp como exemplo, é possível verificar que caso existam mais de 255 objetos na cena, o processo de rotulação poderá ficar comprometido. Identifique a situação em que isso ocorre e proponha uma solução para este problema.
-
Aprimore o algoritmo de contagem apresentado para identificar regiões com ou sem buracos internos que existam na cena. Assuma que objetos com mais de um buraco podem existir. Inclua suporte no seu algoritmo para não contar bolhas que tocam as bordas da imagem. Não se pode presumir, a priori, que elas tenham buracos ou não.
5. Manipulação de histogramas
O objetivo dessa lição é mostrar como tratar histogramas de imagens usando OpenCV. Histogramas são ferramentas interessantes para avaliar características de uma imagem ou de atributos que dela são extraídos.
Um histograma é uma contagem de dados onde se organiza as ocorrências por faixas de valores predefinidos. Em se tratando de imagens digitais em tons de cinza, por exemplo, costuma-se associar um histograma com a contagem de ocorrências de cada um dos possíveis tons em uma imagem. A grosso modo, o histograma oferece uma estimativa da probabilidade de ocorrência dos tons de cinza na imagem.
Exemplos típicos do uso de histogramas podem ser encontrados na segmentação automática de imagens, detecção de movimento e granulometria.
Além disso, a lição deverá explorar o uso dos recursos de captura de vídeo disponíveis no OpenCV para lidar com câmeras conectadas ao sistema.
O exemplo da Listagem Histograma mostra o processo de capturar imagens de uma webcam instalada no computador, calcular os histogramas das componentes de cor das imagens e desenhá-los no canto superior esquerdo da imagem capturada.
#include <iostream>
#include <opencv2/opencv.hpp>
int main(int argc, char** argv){
cv::Mat image;
int width, height;
cv::VideoCapture cap;
std::vector<cv::Mat> planes;
cv::Mat histR, histG, histB;
int nbins = 64;
float range[] = {0, 255};
const float *histrange = { range };
bool uniform = true;
bool acummulate = false;
int key;
cap.open(2);
if(!cap.isOpened()){
std::cout << "cameras indisponiveis";
return -1;
}
cap.set(cv::CAP_PROP_FRAME_WIDTH, 640);
cap.set(cv::CAP_PROP_FRAME_HEIGHT, 480);
width = cap.get(cv::CAP_PROP_FRAME_WIDTH);
height = cap.get(cv::CAP_PROP_FRAME_HEIGHT);
std::cout << "largura = " << width << std::endl;
std::cout << "altura = " << height << std::endl;
int histw = nbins, histh = nbins/2;
cv::Mat histImgR(histh, histw, CV_8UC3, cv::Scalar(0,0,0));
cv::Mat histImgG(histh, histw, CV_8UC3, cv::Scalar(0,0,0));
cv::Mat histImgB(histh, histw, CV_8UC3, cv::Scalar(0,0,0));
while(1){
cap >> image;
cv::split (image, planes);
cv::calcHist(&planes[0], 1, 0, cv::Mat(), histB, 1,
&nbins, &histrange,
uniform, acummulate);
cv::calcHist(&planes[1], 1, 0, cv::Mat(), histG, 1,
&nbins, &histrange,
uniform, acummulate);
cv::calcHist(&planes[2], 1, 0, cv::Mat(), histR, 1,
&nbins, &histrange,
uniform, acummulate);
cv::normalize(histR, histR, 0, histImgR.rows, cv::NORM_MINMAX, -1, cv::Mat());
cv::normalize(histG, histG, 0, histImgG.rows, cv::NORM_MINMAX, -1, cv::Mat());
cv::normalize(histB, histB, 0, histImgB.rows, cv::NORM_MINMAX, -1, cv::Mat());
histImgR.setTo(cv::Scalar(0));
histImgG.setTo(cv::Scalar(0));
histImgB.setTo(cv::Scalar(0));
for(int i=0; i<nbins; i++){
cv::line(histImgR,
cv::Point(i, histh),
cv::Point(i, histh-cvRound(histR.at<float>(i))),
cv::Scalar(0, 0, 255), 1, 8, 0);
cv::line(histImgG,
cv::Point(i, histh),
cv::Point(i, histh-cvRound(histG.at<float>(i))),
cv::Scalar(0, 255, 0), 1, 8, 0);
cv::line(histImgB,
cv::Point(i, histh),
cv::Point(i, histh-cvRound(histB.at<float>(i))),
cv::Scalar(255, 0, 0), 1, 8, 0);
}
histImgR.copyTo(image(cv::Rect(0, 0 ,nbins, histh)));
histImgG.copyTo(image(cv::Rect(0, histh ,nbins, histh)));
histImgB.copyTo(image(cv::Rect(0, 2*histh ,nbins, histh)));
cv::imshow("image", image);
key = cv::waitKey(30);
if(key == 27) break;
}
return 0;
}
Para compilar e executar o programa histogram.cpp, salve-o juntamente com o arquivo Makefile em um diretório e execute a seguinte seqüência de comandos:
$ make histogram
$ ./histogram
A saída do programa histogram é mostrado na Figura 15
5.1. Descrição do programa histogram.cpp
cv::VideoCapture cap;
Fontes de captura de vídeo são acessadas no OpenCV através da classe
VideoCapture. Com ela, o usuário pode abrir um fluxo de vídeo
oriundo de um arquivo de vídeo, sequência de imagens ou de um
dispositivo de captura. Neste último caso, os dispositivos são
identificados por um índice que inicia em 0.
As imagens capturadas nesse exemplo serão extraídas de um fluxo de
vídeo que será conectado ao objeto cap.
std::vector<cv::Mat> planes;
cv::Mat histR, histG, histB;
int nbins = 64;
O cálculo do histograma será realizado para cada uma das componentes
de cor de forma independente. Logo, a separação das componentes em
matrizes independentes será feita no vetor de matrizes
planes. Assim, planes[0], planes[1] e planes[2] armazenarão as
componentes de cor Vermelho, Verde e Azul, respectivamente.
As três matrizes histR, histG e histB guardarão os histogramas
de suas respectivas componentes de cor.
A variável nbins define o tamanho do vetor utilizado para armazenar
os histogramas. O tamanho do histograma não precisa ser
necessariamente o mesmo do ton de cinza máximo previsto para uma
componente de cor (ex: 256 para imagens RGB). É possível especificar a
quantidade de faixas (ou bins) que serão usadas para quantificar
as ocorrências dos tons.
No exemplo, usa-se um total de 64 faixas para um tom de cinza máximo igual a 255. No cálculo, portanto, as ocorrências de tom de cinza na faixa \$[0,3\$] serão contabilizadas no primeiro elemento do array com o histograma; as ocorrências na faixa \$[4,7\$] contarão no segundo elemento do histograma, e assim por diante.
float range[] = {0, 256};
const float *histrange = { range };
É preparada na variável histrange a faixa de valores (mínimo e
máximo) presentes na imagem cujo histograma será calculado. Essa
variável, da forma como é definida, é usada pela função de cálculo de
histograma.
bool uniform = true;
bool acummulate = false;
cap.open(0);
if(!cap.isOpened()){
cout << "cameras indisponiveis";
return -1;
}
cap.set(cv::CAP_PROP_FRAME_WIDTH, 640);
cap.set(cv::CAP_PROP_FRAME_HEIGHT, 480);
width = cap.get(cv::CAP_PROP_FRAME_WIDTH);
height = cap.get(cv::CAP_PROP_FRAME_HEIGHT);
Abre-se a conexão com o primeiro dispositivo de captura de vídeo disponível. Os dispositivos são identificados em sequência. Logo, se um sistema dispõe de duas câmeras, por exemplo, a primeira será associada ao identificador "0" e a segunda ao identificador "1".
Uma vez chamado o método open(), verifica-se se o dispositivo de
captura está devidamente conectado para proceder com o restante das
tarefas.
Neste exemplo, usamos o método set() para atribuir um tamanho aos quadros capturados pela câmera. A escolha foi feita de modo a fixar um tamanho altura x largura igual a 640x480 pixels. Contudo, é importante observar que as resoluções suportadas são dependentes do dispositivo usado para a captura dos quadros, de sorte que essas duas linhas poderão não surtir efeito em alguns casos.
Finalmente, lê-se a largura (width) e altura(height) dos quadros
que serão disponíveis pelo dispositivo. A classe VideoCapture possui
diversos métodos para ajustar os parâmetros de captura para o
dispositivo conectado. Entretanto, na versão do OpenCV em que foram
feitos os testes aqui descritos, alguns podem não funcionar
corretamente dependendo do tipo de dispositivo utilizado.
int histw = nbins, histh = nbins/2;
cv::Mat histImgR(histh, histw, CV_8UC3, cv::Scalar(0,0,0));
cv::Mat histImgG(histh, histw, CV_8UC3, cv::Scalar(0,0,0));
cv::Mat histImgB(histh, histw, CV_8UC3, cv::Scalar(0,0,0));
Define-se a largura e altura das imagens que serão usadas para
desenhar os histogramas de cada uma das componentes de cor. Note que a
altura da imagem é igual à metade da largura para fins de exibição. As
imagens são criadas com o tipo CV_8UC3, ou seja, com 8 bits por
pixel, com tipo de dados unsigned char contendo 3 canais de cor. A
cor, nesse caso, servirá apenas para que o histograma seja desenhado
na cor respectiva de sua componente.
cap >> image;
cv::split (image, planes);
Em um loop infinito, as imagens são capturadas, quadro a quadro, do
dispositivo de entrada conectado e armazenadas no objeto
image. Dispositivos de captura normalmente disponibilizam imagens
com suporte a cor, ou seja, cada matriz possui normalmente três planos
de cor. Logo, os histogramas deverão ser calculados para cada um
desses planos, de modo que a função split() faz a separação adequada
para que se proceda com o cálculo.
Histogramas hiperdimensionais que contabilizam as ocorrências das combinações R,G e B dos pixels de uma imagem são possíveis de serem calculados. Entretanto, normalmente são usadas matrizes esparças para isso. Considerando imagens com 8 bits por pixel para cada plano de cor, seria necessário uma matriz com 256 x 256 x 256 elementos para guardar o histograma até mesmo de uma imagem pequena. Esse processo é dispendioso e, normalmente, não possui muita utilidade.
Na análise de histograma, portanto, geralmente se avalia cada componente de cor de forma independente.
cv::calcHist(&planes[0], 1, 0, Mat(), histR, 1,
&nbins, &histrange,
uniform, acummulate);
cv::calcHist(&planes[1], 1, 0, Mat(), histG, 1,
&nbins, &histrange,
uniform, acummulate);
cv::calcHist(&planes[2], 1, 0, Mat(), histB, 1,
&nbins, &histrange,
uniform, acummulate);
Os histogramas são então calculados para cada uma das componentes
de cor. A função calcHist() do OpenCV recebe, na sequência, os
seguintes argumentos:
-
Uma referência para imagem que se deseja processar;
-
A quantidade de imagens para se calcular o histograma (uma, neste caso);
-
Um ponteiro para o array de canais das imagens. Para apenas um canal, o endereço
0deve ser repassado; -
Uma máscara opcional marcando a região onde se deseja calcular o histograma. Considerando a imagem inteira, fornece-se uma matriz vazia;
-
O array que irá armazenar o histograma;
-
A dimensionalidade do histograma (no exemplo, existe apenas uma dimensão);
-
O endereço da variável que armazena a quantidade de divisões; e
-
Variáveis informando se o histograma é uniforme (divisões de tamanho igual) ou acumulado. Caso não seja uniforme, a variável
histrangedeverá passar uma lista com os limites superiores de cada faixa.
cv::normalize(histR, histR, 0, histImgR.rows, cv::NORM_MINMAX, -1, Mat());
cv::normalize(histG, histB, 0, histImgR.rows, cv::NORM_MINMAX, -1, Mat());
cv::normalize(histB, histB, 0, histImgR.rows, cv::NORM_MINMAX, -1, Mat());
Cada histograma é normalizado em uma faixa de valores que vai de 0
até a quantidade de linhas da imagem onde este será desenhado. A
normalização é feita linearmente entre os valores máximo e mínimo
encontrados na componente de cor.
histImgR.setTo(Scalar(0));
histImgG.setTo(Scalar(0));
histImgB.setTo(Scalar(0));
for(int i=0; i<nbins; i++){
cv::line(histImgR, cv::Point(i, histh),
cv::Point(i, cvRound(histR.at<float>(i))),
cv::Scalar(0, 0, 255), 1, 8, 0);
line(histImgG, cv::Point(i, histh),
cv::Point(i, cvRound(histG.at<float>(i))),
cv::Scalar(0, 255, 0), 1, 8, 0);
line(histImgB, cv::Point(i, histh),
cv::Point(i, cvRound(histB.at<float>(i))),
cv::Scalar(255, 0, 0), 1, 8, 0);
}
As imagens com os desenhos dos histogramas são então
geradas. Inicialmente, todas são preenchidas com 0 (cor preta). Em
seguida, os histogramas são desenhados na forma de um gráfico de
barras usando a função line().
histImgR.copyTo(image(cv::Rect(0, 0 ,nbins, histh)));
histImgG.copyTo(image(cv::Rect(0, histh ,nbins, histh)));
histImgB.copyTo(image(cv::Rect(0, 2*histh ,nbins, histh)));
Finalmente, as imagens dos histogramas são copiadas, uma abaixo da outra, para o canto superior esquerdo da imagem capturada na câmera.
5.2. Exercícios
-
Utilizando o programa exemplos/histogram.cpp como referência, implemente um programa
equalize.cpp. Este deverá, para cada imagem capturada, realizar a equalização do histogram antes de exibir a imagem. Teste sua implementação apontando a câmera para ambientes com iluminações variadas e observando o efeito gerado. Assuma que as imagens processadas serão em tons de cinza. -
Utilizando o programa exemplos/histogram.cpp como referência, implemente um programa
motiondetector.cpp. Este deverá continuamente calcular o histograma da imagem (apenas uma componente de cor é suficiente) e compará-lo com o último histograma calculado. Quando a diferença entre estes ultrapassar um limiar pré-estabelecido, ative um alarme. Utilize uma função de comparação que julgar conveniente.
6. Filtragem no domínio espacial I
A convolução é um processo pelo qual duas funções se combinam para formar uma terceira função no domínio espacial. Tal processo resulta do deslocamento de uma função sobre a outra e do cálculo de uma combinação linear entre ambas em cada ponto do deslocamento.
Em se tratando de uma imagem digital, a convolução é chamada de convolução digital. Sua principal aplicação é na filtragem de sinais, permitindo que características de uma dada imagem sejam alteradas conforme o tipo de efeito que se deseja impor.
A convolução discreta entre duas imagens pode ser definida como
As funções \$f(x,y)\$ e \$g(x,y)\$ normalmente estão associadas à imagem a ser filtrada e ao filtro digital associado.
Existem dois tipos de convolução: a 'convolução linear' e a 'convolução circular'. Na primeira, assume-se que os sinais \$f(x,y)\$ e \$g(x,y)\$ existem em duas regiões com M e N amostras consecutivas, respectivamente, sendo zero fora desssas regiões. A região resultante da convolução terá suporte de tamanho \$M+N-1\$. Fora desta, o resultado da convolução será nulo. Na segunda, assume-se que as sequências \$f(x,y)\$ e \$g(x,y)\$ são periódicas e com um mesmo período \$M=N\$. O resultado da convolução, \$h(x,y)\$ possuirá também o mesmo período \$M\$.
Costuma-se simplificar essa equação e calcular os tons de cinza da imagem filtrada realizando o produto entre os coeficientes de uma pequena matriz comumente denominada 'máscara' e as intensidades dos pixels sobre uma posição específica na imagem.
As máscaras normalmente possuem dimensões de tamanho ímpar (\$3 \times 3\$ elementos , \$5 \times 5\$ elementos, \$7 \times 7\$ elementos, etc), dependendo da intensidade da filtragem que se deseja realizar.
Considere uma imagem digital denotada por \$f(x,y)\$, uma matriz de máscara denotada por \$w(s,t)\$ e uma image filtrada denotada por \$g(x,y)\$. Para uma máscara de tamanho \$3 \times 3\$ elementos, o processo de filtragem no domínio espacial é ilustrado na Figura 16.
No processo, a imagem da máscara é deslocada (pixel a pixel) sobre a imagem a ser filtrada. Para cada deslocamento, calcula-se o somatório do produto entre os valores dos elementos da máscara e os tons de cinza dos pixels que esta sobrepõe e atribui-se o resultado ao pixel respectivo na imagem filtrada.
Muitos efeitos de filtragem são possíveis de se obter modificando os valores da imagem da máscara: borramento, aguçamento e detecção de bordas são os principais deles.
O programa de referência utilizado para essa tarefa, filtroespacial.cpp, é mostrado na Listagem Filtroespacial.
#include <iostream>
#include <opencv2/opencv.hpp>
void printmask(cv::Mat &m) {
for (int i = 0; i < m.size().height; i++) {
for (int j = 0; j < m.size().width; j++) {
std::cout << m.at<float>(i, j) << ",";
}
std::cout << "\n";
}
}
int main(int, char **) {
cv::VideoCapture cap; // open the default camera
float media[] = {0.1111, 0.1111, 0.1111, 0.1111, 0.1111,
0.1111, 0.1111, 0.1111, 0.1111};
float gauss[] = {0.0625, 0.125, 0.0625, 0.125, 0.25,
0.125, 0.0625, 0.125, 0.0625};
float horizontal[] = {-1, 0, 1, -2, 0, 2, -1, 0, 1};
float vertical[] = {-1, -2, -1, 0, 0, 0, 1, 2, 1};
float laplacian[] = {0, -1, 0, -1, 4, -1, 0, -1, 0};
float boost[] = {0, -1, 0, -1, 5.2, -1, 0, -1, 0};
cv::Mat frame, framegray, frame32f, frameFiltered;
cv::Mat mask(3, 3, CV_32F);
cv::Mat result;
double width, height;
int absolut;
char key;
cap.open(0);
if (!cap.isOpened()) // check if we succeeded
return -1;
cap.set(cv::CAP_PROP_FRAME_WIDTH, 640);
cap.set(cv::CAP_PROP_FRAME_HEIGHT, 480);
width = cap.get(cv::CAP_PROP_FRAME_WIDTH);
height = cap.get(cv::CAP_PROP_FRAME_HEIGHT);
std::cout << "largura=" << width << "\n";
;
std::cout << "altura =" << height << "\n";
;
std::cout << "fps =" << cap.get(cv::CAP_PROP_FPS) << "\n";
std::cout << "format =" << cap.get(cv::CAP_PROP_FORMAT) << "\n";
cv::namedWindow("filtroespacial", cv::WINDOW_NORMAL);
cv::namedWindow("original", cv::WINDOW_NORMAL);
mask = cv::Mat(3, 3, CV_32F, media);
absolut = 1; // calcs abs of the image
for (;;) {
cap >> frame; // get a new frame from camera
cv::cvtColor(frame, framegray, cv::COLOR_BGR2GRAY);
cv::flip(framegray, framegray, 1);
cv::imshow("original", framegray);
framegray.convertTo(frame32f, CV_32F);
cv::filter2D(frame32f, frameFiltered, frame32f.depth(), mask,
cv::Point(1, 1), 0);
if (absolut) {
frameFiltered = cv::abs(frameFiltered);
}
frameFiltered.convertTo(result, CV_8U);
cv::imshow("filtroespacial", result);
key = (char)cv::waitKey(10);
if (key == 27) break; // esc pressed!
switch (key) {
case 'a':
absolut = !absolut;
break;
case 'm':
mask = cv::Mat(3, 3, CV_32F, media);
printmask(mask);
break;
case 'g':
mask = cv::Mat(3, 3, CV_32F, gauss);
printmask(mask);
break;
case 'h':
mask = cv::Mat(3, 3, CV_32F, horizontal);
printmask(mask);
break;
case 'v':
mask = cv::Mat(3, 3, CV_32F, vertical);
printmask(mask);
break;
case 'l':
mask = cv::Mat(3, 3, CV_32F, laplacian);
printmask(mask);
break;
case 'b':
mask = cv::Mat(3, 3, CV_32F, boost);
break;
default:
break;
}
}
return 0;
}
Para compilar e executar o programa filtroespacial.cpp, salve-o juntamente com o arquivo Makefile em um diretório e execute a seguinte seqüência de comandos:
$ make filtroespacial
$ ./filtroespacial
A saída do programa filtroespacial apresentará duas janelas: uma com a imagem original capturada e outra com o resultado da filtragem. O filtro inicial escolhido no exemplo é o da média.
6.1. Descrição do programa filtroespacial.cpp
float media[] = {0.1111,0.1111,0.1111,
0.1111,0.1111,0.1111,
0.1111,0.1111,0.1111};
float gauss[] = {0.0625,0.125,0.0625,
0.125,0.25,0.125,
0.0625,0.125,0.0625};
float horizontal[]={-1,0,1,
-2,0,2,
-1,0,1};
float vertical[]={-1,-2,-1,
0,0,0,
1,2,1};
float laplacian[]={0,-1,0,
-1,4,-1,
0,-1,0};
float boost[]={0,-1,0,
-1,5.2,-1,
0,-1,0};
Os filtros usados no exemplo (determinados pelas matrizes de máscara) são de tamanho \$3 \times 3\$ pixels. Cinco tipos de filtros são testados: média, gaussiano, detector de bordas horizontais, detector de bordas verticais e laplaciano. Os coeficientes de cada filtro são armazenados em arrays unidimensionais que serão repassados ao construtor da matriz do filtro.
mask = cv::Mat(3, 3, CV_32F, media);
Esse trecho de código mostra o procedimento padrão para construção da
matriz que será usada como máscara de filtragem. A variável mask
recebe uma matriz de tamanho \$3 \times 3\$ em ponto flutuante
(CV_32F) com valores iniciais iguais ao do array media que é
repassado. Repare que o tipo da matriz precisa ser estabelecido em
ponto flutuante, posto que as operações de cálculo contarão com a
presença de números fracionários.
cap >> frame;
cv::cvtColor(frame, framegray, cv::COLOR_BGR2GRAY);
cv::flip(framegray, framegray, 1);
Em loop infinito, imagens coloridas são capturadas constantemente na
matriz cap e convertidas em equivalentes em tons de cinza usando a
função cvtColor(). A imagem então é invertida horizontalmente com a
função flip(). A inversão é feita apenas para fins de tornar a
interação com o programa exemplo semelhante à de um espelho.
framegray.convertTo(frame32f, CV_32F);
filter2D(frame32f, frameFiltered, frame32f.depth(), mask, cv::Point(1,1), 0);
Este trecho é responsável pelo cálculo da filtragem espacial. Cada
imagem em tom de cinza armazenada na variável framegray é convertida
para outra equivalente com representação em ponto flutuante -
frame32f. A conversão é necessária devido aos tipos de operação que
serão realizados pela função filter2D(). Observe apenas que OpenCV
replica os pixels na borda ('ao invés de preencher de zeros') durante
o processo de filtragem.
A função filter2d() recebe então a matriz da imagem em ponto
flutuante - frame32f - e produz a matriz frameFiltered, de acordo
com o tipo do elemento da matriz de entrada - neste caso, CV_32F (ou
float). O objeto Point(1,1) que é repassado como próximo argumento
identifica a origem do sistema de coordenadas atribuído para a
máscara que, neste caso, é o ponto central da matriz.
if(absolut){
frameFiltered=cv::abs(frameFiltered);
}
frameFiltered.convertTo(result, CV_8U);
Caso a opção de módulo esteja selecionada, o cálculo é então procedido. A imagem filtrada é então convertida para tons de cinza para posterior exibição na tela.
O restante do código trata apenas da adaptação da matriz mask
conforme o filtro escolhido pelo usuário para ser aplicado à imagem
capturada.
6.2. Exercícios
-
Utilizando o programa exemplos/filtroespacial.cpp como referência, implemente um programa
laplgauss.cpp. O programa deverá acrescentar mais uma funcionalidade ao exemplo fornecido, permitindo que seja calculado o laplaciano do gaussiano das imagens capturadas. Compare o resultado desse filtro com a simples aplicação do filtro laplaciano.
7. Filtragem no domínio espacial II
Este capítulo visa explorar um pouco mais do uso de filtragem espacial aplicando seus princípios para simular uma técnica de fotografia denominada tilt-shift.
A técnica fotográfica de tilt-shift envolve o uso de deslocamentos e rotações entre a lente e o plano de projeção (onde fica filme fotográfico ou o sensor da câmera) de modo a desfocar seletivamente regiões do assunto.
O princípio básico dessa técnica é ilustrado na Figura 17.
Na lente normal, o plano de projeção é paralelo ao plano de foco com o assunto que se deseja registrar. Quando a lente é submetida a uma inclinação (tilt), o plano de foco forma um ângulo diferente de zero com o plano de projeção, mudando assim a região que ficará em foco na imagem registrada pela câmera. Se a lente for deslocada para cima ou para baixo (shift), é possível também escolher seletivamente a região que ficará em foco, complementando o uso da técnica.
A técnica de tilt-shift consegue criar belos efeitos fotográficos, simulando miniaturas. O foco seletivo que a lente produz engana o olho humano, dando a impressão que a imagem foi registrada de uma cena em miniatura. Tomando a imagem usando ângulos e proporções adequadas do assunto, dá para se produzir versões em minatura de cenas reais que podem ser bastante convincentes.
Lentes que produzem esse efeito não são baratas quando comparadas a lentes normais. Entrentanto, o efeito produzido por estas lentes pode ser reproduzido usando técnicas simples de processamento digital de imagens.
O princípio utilizado para simular a lente tilt-shift é combinar a imagem original com sua versão filtrada com filtro passa-baixas, de sorte a produzir nas proximidades da borda o efeito do borramento enquanto se mantém na região central a imagem sem borramento.
Uma forma de combinar pode ser realizada com a função addWeighted() do
OpenCV. Ela opera calculando a combinação linear de duas imagens
\$f_0(x,y)\$ e \$f_1(x,y)\$ pela
equação \$g(x,y) = (1 - \alpha)f_0(x,y) + \alpha f_1(x,y)\$, para um
dado valor de \$\alpha\$ fornecido.
O programa de referência utilizado para exemplificar o uso da função sugerida, addweighted.cpp, é mostrado na Listagem Addweighted.
#include <iostream>
#include <cstdio>
#include <opencv2/opencv.hpp>
double alfa;
int alfa_slider = 0;
int alfa_slider_max = 100;
int top_slider = 0;
int top_slider_max = 100;
cv::Mat image1, image2, blended;
cv::Mat imageTop;
char TrackbarName[50];
void on_trackbar_blend(int, void*){
alfa = (double) alfa_slider/alfa_slider_max ;
cv::addWeighted(image1, 1-alfa, imageTop, alfa, 0.0, blended);
cv::imshow("addweighted", blended);
}
void on_trackbar_line(int, void*){
image1.copyTo(imageTop);
int limit = top_slider*255/100;
if(limit > 0){
cv::Mat tmp = image2(cv::Rect(0, 0, 256, limit));
tmp.copyTo(imageTop(cv::Rect(0, 0, 256, limit)));
}
on_trackbar_blend(alfa_slider,0);
}
int main(int argvc, char** argv){
image1 = cv::imread("blend1.jpg");
image2 = cv::imread("blend2.jpg");
image2.copyTo(imageTop);
cv::namedWindow("addweighted", 1);
std::sprintf( TrackbarName, "Alpha x %d", alfa_slider_max );
cv::createTrackbar( TrackbarName, "addweighted",
&alfa_slider,
alfa_slider_max,
on_trackbar_blend );
on_trackbar_blend(alfa_slider, 0 );
std::sprintf( TrackbarName, "Scanline x %d", top_slider_max );
cv::createTrackbar( TrackbarName, "addweighted",
&top_slider,
top_slider_max,
on_trackbar_line );
on_trackbar_line(top_slider, 0 );
cv::waitKey(0);
return 0;
}
Para compilar e executar o programa addweighted.cpp, salve-o juntamente com o arquivo Makefile em um diretório juntamente com as imagens exemplos/blend1.jpg e exemplos/blend2.jpg e execute a seguinte seqüência de comandos:
$ make addweighted
$ ./addweighted
A saída do programa addweighted apresentará uma janela com duas barras de controle: uma que regula o valor de \$alpha\$ e outra que indica a região que será copiada de uma das imagens de entrada na imagem da composição.
Utilizando os recursos do exemplo, é possível conceber uma função de
ponderação para combinar a imagem original com sua versão borrada por
um filtro da média. Entretanto, o desfoque não deve alterar a região
central da imagem final para que o efeito do tiltshift funcione.
Tal processo pode ser modelado usando uma função que define a região de desfoque ao longo do eixo vertical da imagem. Uma possível função que modela esse efeito é dada por
\$\alpha (x) = \frac{1}{2} ( \tanh \frac{x-l1}{d}-tanh\frac{x-l2}{d} )\$
Onde \$l1\$ e \$l2\$ são as linhas cujo valor de \$\alpha\$ assume valor em torno de 0.5, caso os dois valores possuam uma distância adequada um do outro, e \$d\$ indica a força do decaimento da região totalmente oriunda da imagem original para a região totalmente oriunda da imagem borrada.
Para valores \$l1 = -20 \$, \$l2 = 30\$, e \$d = 6\$, por exemplo, a função de ponderação se comportaria como ilustrado na Figura 18.
Assumindo que \$\alpha(x)\$ pondere a imagem original (denotada por
stem \$f(x,y)\$) e \$1-\alpha(x)\$ pondere a imagem borrada
(denotada por \$bf(x,y)\$), a composição \$g(x,y) = \alpha(x)
f(x,y) + (1-\alpha(x)) bf(x,y)\$ produzirá o efeito de tiltshift
desejado.
O processo de ponderação pode ser realizado por intermédio da função
multiply() do OpenCV, destinada à multiplicação de matrizes
elemento-a-elemento. Cria-se a imagem que irá ponderar as linhas da
imagem original e seu negativo irá ponderar as linhas da imagem
borrada. A combinação linear dessas duas imagens fara o efeito
simulado de tiltshift. A Figura 19 ilustra
possíveis imagens que poderiam ser usadas para ponderação no
processo. A da esquerda ponderaria a imagem original e a da direita a
imagem borrada.
tiltshift7.1. Exercícios
-
Utilizando o programa exemplos/addweighted.cpp como referência, implemente um programa
tiltshift.cpp. Três ajustes deverão ser providos na tela da interface:-
um ajuste para regular a altura da região central que entrará em foco;
-
um ajuste para regular a força de decaimento da região borrada;
-
um ajuste para regular a posição vertical do centro da região que entrará em foco. Finalizado o programa, a imagem produzida deverá ser salva em arquivo.
-
-
Utilizando o programa exemplos/addweighted.cpp como referência, implemente um programa
tiltshiftvideo.cpp. Tal programa deverá ser capaz de processar um arquivo de vídeo, produzir o efeito de tilt-shift nos quadros presentes e escrever o resultado em outro arquivo de vídeo. A ideia é criar um efeito de miniaturização de cenas. Descarte quadros em uma taxa que julgar conveniente para evidenciar o efeito de stop motion, comum em vídeos desse tipo.
Parte II: Processamento de Imagens no Domínio da Frequência
8. A Tranformada Discreta de Fourier
O objetivo desse capítulo é apresentar a Transformada Discreta de Fourier bidimensional. A Transformada de Fourier é uma transformada capaz de expressar um sinal contínuo como uma combinação de funções de base senoidais ponderadas por coeficientes. Em se tratando de sinais discretos, como é o caso de imagens digitais, a Transformada Discreta de Fourier (ou DFT) é a transformada utilizada.
Para uma imagem digital, a Transformada Discreta de Fourier é capaz de fornecer uma representação alternativa dessa imagem no domínio da frequência, evidenciando degradações que não são facilmente tratadas no domínio espacial. Exemplos de problemas dessa natureza são as interferências periódicas nas transmissões de sinais analógicos, ou repetição de padrões presentes em figuras antigas ou fotos de jornais.
Um exemplo de fotografia corrompida por um padrão senoidal é mostrada na Figura 20. Note que existe uma espécie de grade de pontos presentes nessa imagem.
O espectro de magnitude da Transformada Discreta de Fourier da imagem da Figura 20 é mostrada na Figura 21. Perceba que há um conjunto de manchas simétricas que surgem longe dos eixos, destacando-se do restante do sinal transformado. São essas contribuições as causadoras da grade de pontos e podem ser removidas com o uso de filtros adequados.
Para realizar o cálculo da Transformada Discreta de Fourier, é necessário realizar uma série de passos que envolvem a preparação da matriz complexa que deve ser fornecida à função de cálculo da DFT.
Para ilustrar o uso da Transformada Discreta de Fourier, considere o exemplo mostrado na Listagem 9.
#include <iostream>
#include <vector>
#include <opencv2/opencv.hpp>
void swapQuadrants(cv::Mat& image) {
cv::Mat tmp, A, B, C, D;
// se a imagem tiver tamanho impar, recorta a regiao para o maior
// tamanho par possivel (-2 = 1111...1110)
image = image(cv::Rect(0, 0, image.cols & -2, image.rows & -2));
int centerX = image.cols / 2;
int centerY = image.rows / 2;
// rearranja os quadrantes da transformada de Fourier de forma que
// a origem fique no centro da imagem
// A B -> D C
// C D B A
A = image(cv::Rect(0, 0, centerX, centerY));
B = image(cv::Rect(centerX, 0, centerX, centerY));
C = image(cv::Rect(0, centerY, centerX, centerY));
D = image(cv::Rect(centerX, centerY, centerX, centerY));
// swap quadrants (Top-Left with Bottom-Right)
A.copyTo(tmp);
D.copyTo(A);
tmp.copyTo(D);
// swap quadrant (Top-Right with Bottom-Left)
C.copyTo(tmp);
B.copyTo(C);
tmp.copyTo(B);
}
int main(int argc, char** argv) {
cv::Mat image, padded, complexImage;
std::vector<cv::Mat> planos;
image = imread(argv[1], cv::IMREAD_GRAYSCALE);
if (image.empty()) {
std::cout << "Erro abrindo imagem" << argv[1] << std::endl;
return EXIT_FAILURE;
}
// expande a imagem de entrada para o melhor tamanho no qual a DFT pode ser
// executada, preenchendo com zeros a lateral inferior direita.
int dft_M = cv::getOptimalDFTSize(image.rows);
int dft_N = cv::getOptimalDFTSize(image.cols);
cv::copyMakeBorder(image, padded, 0, dft_M - image.rows, 0, dft_N - image.cols, cv::BORDER_CONSTANT, cv::Scalar::all(0));
// prepara a matriz complexa para ser preenchida
// primeiro a parte real, contendo a imagem de entrada
planos.push_back(cv::Mat_<float>(padded));
// depois a parte imaginaria com valores nulos
planos.push_back(cv::Mat::zeros(padded.size(), CV_32F));
// combina os planos em uma unica estrutura de dados complexa
cv::merge(planos, complexImage);
// calcula a DFT
cv::dft(complexImage, complexImage);
swapQuadrants(complexImage);
// planos[0] : Re(DFT(image)
// planos[1] : Im(DFT(image)
cv::split(complexImage, planos);
// calcula o espectro de magnitude e de fase (em radianos)
cv::Mat magn, fase;
cv::cartToPolar(planos[0], planos[1], magn, fase, false);
cv::normalize(fase, fase, 0, 1, cv::NORM_MINMAX);
// caso deseje apenas o espectro de magnitude da DFT, use:
cv::magnitude(planos[0], planos[1], magn);
// some uma constante para evitar log(0)
// log(1 + sqrt(Re(DFT(image))^2 + Im(DFT(image))^2))
magn += cv::Scalar::all(1);
// calcula o logaritmo da magnitude para exibir
// com compressao de faixa dinamica
log(magn, magn);
cv::normalize(magn, magn, 0, 1, cv::NORM_MINMAX);
// exibe as imagens processadas
cv::imshow("Imagem", image);
cv::imshow("Espectro de magnitude", magn);
cv::imshow("Espectro de fase", fase);
cv::waitKey();
return EXIT_SUCCESS;
}
8.1. Descrição do programa dftimage.cpp
A operação de cálculo da Transformada Discreta de Fourier em OpenCV pode ser realizada pelo seguinte conjunto de passos:
-
Obtenção da imagem a ser processada.
-
Padding da imagem com zeros para que seu tamanho seja processável pelo algoritmo de cálculo da FFT (Fast Fourier Transform) implementada no OpenCV.
-
Criação de uma imagem com dois canais (Real e Imaginário), em ponto flutuante, para ser submetida à função de cálculo da DFT.
-
Cálculo da DFT da imagem.
-
Troca de quadrantes para que a origem da imagem transformada fique no centro. Isso ajuda na visualização e projeto de filtros usando o espectro de magnitude da transformada.
-
Cálculo do espectro de magnitude e de fase da transformada.
-
Compressão de faixa dinâmica do espectro de magnitude para melhor visualização.
-
Exibição dos espectros de magnitude e fase da transformada.
image = imread(argv[1], cv::IMREAD_GRAYSCALE);
if (image.empty()) {
std::cout << "Erro abrindo imagem" << argv[1] << std::endl;
return EXIT_FAILURE;
}
A imagem a ser processada é lida do disco e armazenada na variável image em
formato de escala de cinza. Caso a imagem não seja lida com sucesso, o programa
finaliza, apresentando uma mensagem de erro. A conversão para escala de cinza é
necessária pois o programa foi desenvolvido para processar imagens em escala de
cinza.
dft_M = cv::getOptimalDFTSize(image.rows);
dft_N = cv::getOptimalDFTSize(image.cols);
A função getOptimalDFTSize() identifica os melhores valores com base
no tamanho fornecido para acelerar o processo de cálculo da DFT com
base em algum algoritmo otimizado. Segundo a documentação do OpenCV,
valores múltiplos de dois, três e cinco produzem resultados
melhores. Os valores de tamanho ideal para a quantidade de linhas e
colunas da imagem são armazenados nas variáveis dft_M e dft_N,
respectivamente.
cv::copyMakeBorder(image, padded, 0,
dft_M - image.rows, 0,
dft_N - image.cols,
cv::BORDER_CONSTANT, cv::Scalar::all(0));
A função copyMakeBorder() cria uma versão da imagem fornecida com
uma borda preenchida com zeros e ajustada ao tamanho ótimo para
cálculo da DFT, conforme indicado pelo uso da função
getOptimalDFTSize(). Para uma imagem image fornecida, a saída é
produzida na imagem padded. Caso a imagem fornecida já
possua dimensões apropriadas, a imagem de saída será igual à de
entrada.
planos.push_back(cv::Mat_<float>(padded));
planos.push_back(cv::Mat::zeros(padded.size(), CV_32F));
cv::merge(planos, complexImage);
Esse trecho de código prepara a matriz complexa que será fornecida à função de
cálculo do dft. Ambas são enfileiradas em um vetor de matrizes e a função
merge() se encarrega de produzir a matriz complexa a partir das matrizes
presentes no vetor planos.
// calcula o dft
cv::dft(complexImage, complexImage);
O cálculo do DFT é realizado. Perceba que tanto a matriz de entrada quanto a de saída passadas como parâmetro podem ser a mesma.
// realiza a troca de quadrantes
swapQuadrants(complexImage);
Finalizado o cálculo da DFT, a função swapQuadrants() realiza a
troca de quadrantes. Para melhor visualização do espectro de magnitude da transformada, é necessário
que o sinal transformado seja deslocado de modo que a origem do sinal fique
posicionada no centro da imagem, como ilustra a Figura 22.
A operação de troca de quadrantes realizada pela função swapQuadrants(). Ela
recebe a referência para a matriz que contém a imagem transformada e
troca seus quadrantes. Caso a imagem possua tamanho ímpar, ela é
diminuída de tamanho em um pixel para que a troca dos quadrantes
seja feita usando tamanhos imagens de iguais. Normalmente, trata-se a
imagem que será submetida ao cálculo da DFT para que possua dimensões
de ordem par, de sorte que essa linha não deverá alterar o tamanho das
imagens usualmente fornecidas.
// planos[0] : Re(DFT(image)
// planos[1] : Im(DFT(image)
cv::split(complexImage, planos);
Terminada a troca de quadrantes, a imagem transformada é separada em suas
componentes real e imaginária com a função split(). As componentes são então
armazenadas na forma de matrizes no vetor planos.
// calcula o espectro de magnitude e de fase (em radianos)
cv::Mat magn, fase;
cv::cartToPolar(planos[0], planos[1], magn, fase, false);
cv::normalize(fase, fase, 0, 1, cv::NORM_MINMAX);
// caso deseje apenas o espectro de magnitude da DFT, use:
cv::magnitude(planos[0], planos[1], magn);
A função cartToPolar() calcula o espectro de magnitude e de fase a partir das
componentes real e imaginária da imagem transformada. Esta função foi escolhida
especificamente para o exemplo porque já consegue obter os dois espectros ao
mesmo tempo. Entretanto, perceba que logo após a normalização do espectro de
fase, há uma chamada à função magnitude().
A função magnitude() calcula somente o espectro de magnitude a partir das
componentes real e imaginária e, caso o espectro de fase não seja necessário
para a análise do sinal transformado, esta é a função mais indicada para o
cálculo do espectro de magnitude.
magn += cv::Scalar::all(1);
log(magn, magn);
cv::normalize(magn, magn, 0, 1, cv::NORM_MINMAX);
Esse trecho de código serve para realizar a compressão de faixa dinâmica e
normalização do espectro de magnitude na faixa \$0,1\$ para fins de exibição.
A compressão de faixa dinâmica é logaritmica e, para evitar erros de cálculo nas
situações em que algum dos elementos da matriz magn seja igual a zero, é
somado um valor constante a todos os elementos da matriz igual a um.
8.2. Exercícios
-
Utilizando os programa exemplos/dftimage.cpp, calcule e apresente o espectro de magnitude da imagem [fig_senoide256png].
-
Compare o espectro de magnitude gerado para a figura [fig_senoide256png] com o valor teórico da transformada de Fourier da senoide.
-
Usando agora o filestorage.cpp, mostrado na [exa_filestorage] como referência, adapte o programa exemplos/dftimage.cpp para ler a imagem em ponto flutuante armazenada no arquivo YAML equivalente (ilustrado na [ex-senoideyml]).
-
Compare o novo espectro de magnitude gerado com o valor teórico da transformada de Fourier da senoide. O que mudou para que o espectro de magnitude gerado agora esteja mais próximo do valor teórico? Porque isso aconteceu?
9. Filtragem no Domínio da Frequência
O objetivo da filtragem no domínio da frequência é remover ruídos e distorções geralmente de natureza periódica numa imagem. Neste capítulo, veremos como criar um filtro de frequência e aplicá-lo a uma imagem utilizando a DFT. O filtro de frequência é uma matriz que possui o mesmo tamanho da imagem e que é multiplicada pela transformada de Fourier da imagem para filtrar eventuais problemas que existam na imagem.
O processo de filtragem envolve uma sequência de passos cuja parte delas já foi explorada em outra lição. Os passos para realizar o processo de filtragem são:
-
Obtenção da imagem a ser processada.
-
Padding da imagem com zeros para que seu tamanho seja processável pelo algoritmo de cálculo da FFT (Fast Fourier Transform) implementada no OpenCV.
-
Criação de uma imagem com dois canais (Real e Imaginário), em ponto flutuante, para ser submetida à função de cálculo da DFT.
-
Cálculo da DFT da imagem.
-
Troca de quadrantes para que a origem da imagem transformada fique no centro. Isso ajuda na visualização e projeto de filtros usando o espectro de magnitude da transformada.
-
Criação de um filtro de frequência.
-
Multiplicação do filtro de frequência pela imagem transformada.
-
Troca de quadrantes para que a origem da imagem transformada volte para o canto superior esquerdo.`
-
Remoção do padding da imagem (caso necessário).
-
Visualização da imagem filtrada.
Nessa sequência de passos, o processo de filtragem inicial com a criação do
filtro \$H(u,v)\$, tal que a imagem filtrada é dada por \$G(u,v) = H(u,v)
\cdot F(u,v)\$, onde \$F(u,v)\$ é a transformada de Fourier da imagem de
entrada e \$G(u,v)\$ é a transformada de Fourier da imagem filtrada. Esse
produto de matrizes é realizado elemento a elemento, e é implementado no OpenCV
pela função mulSpectrums().
Para compilar e executar o programa dftfilter.cpp, salve-o juntamente com o arquivo Makefile e a imagem biel.png em um diretório e execute a seguinte seqüência de comandos:
$ make dftfilter
$ ./dftfilter biel.png
A saída do programa dftfilter é mostrado na Figura 23.
9.1. Descrição do programa dftfilter.cpp
O trecho de código a seguir mostra a chamada da função de criação do filtro de frequência e a aplicação do filtro na imagem.
cv::Mat filter;
makeFilter(complexImage, filter);
cv::mulSpectrums(complexImage, filter, complexImage, 0);
A função makeFilter() é responsável por criar o filtro de frequência. Ela
recebe a imagem transformada e a matriz que será preenchida com o filtro de
frequência é retornada no segundo parâmetro, que é passado na forma de uma
referência.
void makeFilter(const cv::Mat &image, cv::Mat &filter){
cv::Mat_<float> filter2D(image.rows, image.cols);
int centerX = image.cols / 2;
int centerY = image.rows / 2;
int radius = 20;
for (int i = 0; i < image.rows; i++) {
for (int j = 0; j < image.cols; j++) {
if (pow(i - centerY, 2) + pow(j - centerX, 2) <= pow(radius, 2)) { (1)
filter2D.at<float>(i, j) = 1;
} else {
filter2D.at<float>(i, j) = 0;
}
}
}
cv::Mat planes[] = {cv::Mat_<float>(filter2D),
cv::Mat::zeros(filter2D.size(), CV_32F)}; (2)
cv::merge(planes, 2, filter); (3)
}
| 1 | A função makefilter() cria um filtro ideal de tamanho igual ao da imagem. Do
centro da matriz até uma distância de 20 pixels, o valor filtro é igual a 1.
Fora desse raio, o valor do filtro é igual a 0. O tipo de dado usado para criar
a matriz é float, pois é o tipo de dado usado para armazenar os valores da DFT. |
| 2 | Para criar o filtro de frequência, é necessário criar uma matriz com dois
canais, um para a parte real e outro para a parte imaginária. Daí a criação do
vetor de matrizes planes[] e… |
| 3 | A chamada da função merge() para criar a matriz de dois canais. |
// calcula a DFT inversa
swapQuadrants(complexImage);
cv::idft(complexImage, complexImage);
cv::split(complexImage, planos);
// recorta a imagem filtrada para o tamanho original
// selecionando a regiao de interesse (roi)
cv::Rect roi(0, 0, image.cols, image.rows);
cv::Mat result = planos[0](roi);
A última parte do código mostra como recuperar a imagem filtrada. A transformada
de Fourier inversa é calculada com a função idft(). A função split() divide
a imagem multicanal em duas matrizes, uma para a parte real (planos[0]) e
outra para a parte imaginária (planos[1]).
A imagem filtrada é obtida selecionando a região de interesse da imagem
correspondente ao tamanho original da imagem de entrada usando um objeto da
classe Rect para esse fim. A imagem filtrada é armazenada na variável result
e posteriormente normalizada para exibição.
9.2. Exercícios
-
Utilizando o programa exemplos/dftfilter.cpp como referência, implemente o filtro homomórfico para melhorar imagens com iluminação irregular. Crie uma cena mal iluminada e ajuste os parâmetros do filtro homomórfico para corrigir a iluminação da melhor forma possível. Assuma que a imagem fornecida é em tons de cinza.
Parte III: Segmentação de imagens
10. Detecção de bordas com o algoritmo de Canny
O detector de bordas de Canny é sabidamente reconhecido como um dos mais rápidos e eficientes algoritmos para encontrar descontinuidades em uma imagem. Ele produz como resultado uma imagem binária contendo os pontos de borda obtidos a partir de uma imagem, para um conjunto de parâmetros de configuração.
Em linhas gerais, o algoritmo de Canny procura descobrir bordas situadas em máximos locais do gradiente de uma image, e pode ser sumarizado pelos seguintes passos:
-
Convolução com o filtro Gaussiano, cálculo da magnitude e ângulo do gradiente.
-
Afinação das cristas largas do gradiente.
-
Classificação dos pontos quanto às orientações Horizontal, Vertical, \(+45^\text{o}\), e \(-45^\text{o}\) (intervalos de \(\pm 22.5^\text{o}\)).
-
Para os vizinhos na orientação determinada para o pixel, verificar os seus gradientes.
-
Supressão de não máximos: se o valor da magnitude do gradiente \(M(x,y)\) for inferior a pelo menos um de seus vizinhos, faça \(g_N(x,y)=0\); caso contrário, faça \(g_N(x,y) = M(x,y)\). A imagem \(g_N(x,y)\) é a imagem com supressão.
-
-
Limiarização com histerese é usada para a quebra do contorno (borda tracejada).
-
Dois limiares \(T_1\) e \(T_2\). \(T_1 > T_2\) são usados.
-
Se o pixel é tal que \(g_N(x,y) \ge T_1\), é assumido como ponto de borda forte.
-
Para os pixels restantes, aqueles em que \(g_N(x,y) \ge T_2\), são assumidos como ponto de borda fraco.
-
Para todos os vizinhos dos pontos de borda fraco, procurar nos seus 8-vizinhos se há algum ponto de borda forte. Caso haja, este é marcado como parte da fronteira.
-
Sugestão de Canny: \(T_H/T_L = 3/1\), ou \(T_H/T_L =2/1\)
-
Um exemplo de aplicação desse algoritmo na imagem da Figura 24 é mostrado na Figura 25. Observe que as bordas encontradas são bem localizadas e geralmente possuem espessura igual a 1.
O programa que gerou essa imagem é mostrado na Listagem 10.
#include <iostream>
#include "opencv2/opencv.hpp"
int top_slider = 10;
int top_slider_max = 200;
char TrackbarName[50];
cv::Mat image, border;
void on_trackbar_canny(int, void*){
cv::Canny(image, border, top_slider, 3*top_slider);
cv::imshow("Canny", border);
}
int main(int argc, char**argv){
image= cv::imread(argv[1], cv::IMREAD_GRAYSCALE);
sprintf( TrackbarName, "Threshold inferior", top_slider_max );
cv::namedWindow("Canny",1);
cv::createTrackbar( TrackbarName, "Canny",
&top_slider,
top_slider_max,
on_trackbar_canny );
on_trackbar_canny(top_slider, 0 );
cv::waitKey();
cv::imwrite("cannyborders.png", border);
return 0;
}
Para compilar e executar o programa canny.cpp, salve-o juntamente com os arquivo Makefile e a imagem biel.png em um diretório e execute a seguinte seqüência de comandos:
$ make canny
$ ./canny biel.png
O programa disponibilizará uma scrollbar que regula o valor do
limiar \(T_1\). O valor do limiar \(T_2\) é
determinado automaticamente usando a proporção \(T_1 = 3
T_1\). Ao ser finalizado - quando uma tecla é pressionada - o programa
escreve a imagem de bordas no arquivo de nome cannyborders.png.
Valores diferentes para o limiar escolhido produzem imagens de bordas diferentes.
A função de destaque nesse programa exemplo é apenas a função
Canny().
cv::Canny(image, border, top_slider, 3*top_slider);
Os dois primeiros argumentos indicam a imagem a ser processada, a
matriz onde a imagem de bordas será escrita, e os limiares
\(T_1\) e \(T_2\), neste caso representado pelas
quantidades top_slider e 3*top_slider.
10.1. Canny e a arte com pontilhismo
O algoritmo de Canny de fato é útil para diversas aplicações em processamento de imagens e visão artificial. Informações de bordas podem ser usadas para melhorar algoritmos de segmentação automática ou para encontrar objetos em cenas e pontos de interesse.
Entretanto, nesta lição, a proposta de uso do algoritmo é para desenvolver arte digital. A ideia é usar uma imagem de referência para criar uma nova imagem usando efeitos artísticos pontilhistas.
O pontilhismo é uma técnica de desenho impressionista onde o quadro é pintado usando apenas pontos. Um dos artistas pioneiros nessa técnica foi George Seurat. Vários dos seus trabalhos podem ser vistos online no site georgesseurat.org.
Simular no computador um efeito pontilhista não é muito trabalhoso. Uma estratégia simples é utilizar uma imagem de referência e criar uma outra imagem desenhada usando pequenos círculos. Em suma, percorre-se a imagem de referência e para cada pixel, desenha-se um círculo com a mesma cor na posição correspondente na imagem pontilhista.
Efeitos pontilhistas interessantes podem ser criados com variantes simples dessa técnica. Exemplo: pular sequências de pixels na imagem de referência para dar a impressão de que os pontos estão separados na tela - isso é bastante comum na arte pontilhista. Outro efeito interessante é realizar deslocamentos aleatórios nos centros dos círculos, para que a imagem gerada permaneca menos artificial. Finalmente, é razoável percorrer a matriz de referência usando uma sequência aleatória, principalmente quando a técnica pontilhista realiza a sobreposição de círculos.
Um exemplo de imagem pontilhista é mostrada na Figura 26.
O programa que gerou essa imagem é mostrado na Listagem 11.
#include <algorithm>
#include <cstdlib>
#include <ctime>
#include <fstream>
#include <iomanip>
#include <iostream>
#include <numeric>
#include <opencv2/opencv.hpp>
#include <vector>
#define STEP 5
#define JITTER 3
#define RAIO 3
int main(int argc, char** argv) {
std::vector<int> yrange;
std::vector<int> xrange;
cv::Mat image, frame, points;
int width, height, gray;
int x, y;
image = cv::imread(argv[1], cv::IMREAD_GRAYSCALE);
std::srand(std::time(0));
if (image.empty()) {
std::cout << "Could not open or find the image" << std::endl;
return -1;
}
width = image.cols;
height = image.rows;
xrange.resize(height / STEP);
yrange.resize(width / STEP);
std::iota(xrange.begin(), xrange.end(), 0);
std::iota(yrange.begin(), yrange.end(), 0);
for (uint i = 0; i < xrange.size(); i++) {
xrange[i] = xrange[i] * STEP + STEP / 2;
}
for (uint i = 0; i < yrange.size(); i++) {
yrange[i] = yrange[i] * STEP + STEP / 2;
}
points = cv::Mat(height, width, CV_8U, cv::Scalar(255));
std::random_shuffle(xrange.begin(), xrange.end());
for (auto i : xrange) {
std::random_shuffle(yrange.begin(), yrange.end());
for (auto j : yrange) {
x = i + std::rand() % (2 * JITTER) - JITTER + 1;
y = j + std::rand() % (2 * JITTER) - JITTER + 1;
gray = image.at<uchar>(x, y);
cv::circle(points, cv::Point(y, x), RAIO, CV_RGB(gray, gray, gray),
cv::FILLED, cv::LINE_AA);
}
}
cv::imwrite("pontos.jpg", points);
return 0;
}
Para compilar e executar o programa pontilhismo.cpp, salve-o juntamente com o arquivo Makefile e a imagem biel.png em um diretório e execute a seguinte seqüência de comandos:
$ make pontilhismo
$ ./pontilhismo biel.png
10.2. Descrição do programa pontilhismo.cpp
O programa pontilhismo.cpp não introduz novas funcionalidades da
biblioteca de programação OpenCV. Entretanto, algumas classes da
STL, a biblioteca padrão
de gabaritos do C++ estão presentes no código para facilitar a criação
de alguns efeitos. Logo, é importante discorrer um pouco sobre seu uso
no exemplo.
std::vector<int> yrange;
std::vector<int> xrange;
Define-se dois arrays de índices que servirão para identificar
elementos da imagem de referência. Os tamanhos dos arrays xrange e
yrange são determinados como frações da altura e da largura da
imagem, respectivamente. Isso é feito para que na geração da imagem
pontilhista, apenas alguns pontos sejam amostrados na imagem de
referência, evitando sobrecarga visual.
A grandeza STEP define o passo usado para varrer a imagem de
referência. No exemplo, usamos STEP igual a 5 pixels, ou seja,
considerando as duas dimensões da imagem, apenas 1 em cada
\(5 \times 5 = 25\) pixels de uma janela é usado para criar um
círculo.
std::iota(xrange.begin(), xrange.end(), 0);
std::iota(yrange.begin(), yrange.end(), 0);
for(uint i=0; i<xrange.size(); i++){
xrange[i]= xrange[i]*STEP+STEP/2;
}
for(uint i=0; i<yrange.size(); i++){
yrange[i]= yrange[i]*STEP+STEP/2;
}
Os arrays xrange e yrange são preenchidos com valores sequenciais
iniciando em 0 e, em seguida, esses valores recebem um ganho igual a
STEP e um deslocamento STEP/2, para que o processo de amostragem
na imagem de referência se dê no centro da janela.
std::random_shuffle(xrange.begin(), xrange.end());
A função random_shuffle() recebe como parâmetros 2 iteradores: uma
para o início do array e outro para o final. Como resultado, a função
embaralha aleatoriamente todos seus elementos. Se observado, esse
processo é feito uma vez para o array de índices das linhas -
xrange - e, para cada linha, embaralha-se o array de índices das
colunas - yrange.
Os loops descritos por for(auto i : xrange) e for(auto j : yrange)
são construções na especificação C++11 e servem para fazer as
variáveis i e j assumirem, a cada passada no loop, os valores dos
arrays xrange e yrange de forma consecutiva.
x = i+rand()%(2*JITTER)-JITTER+1;
y = j+rand()%(2*JITTER)-JITTER+1;
O valor das coordenadas do ponto cujo tom de cinza será amostrado na
imagem de referência é determinado pela posição do centro da janela
mais um deslocamento aleatório em ambas as direções. Esse deslocamento
é determinado pela grandeza JITTER (igual a 3 pixels).
Variações das grandezas STEP e JITTER podem ser modificadas para
uso em imagens de tamanhos diferentes.
cv::circle(points, cv::Point(y, x), RAIO, CV_RGB(gray, gray, gray),
cv::FILLED, cv::LINE_AA);
A função circle() é usada para traçar um círculo de raio
especificado em um ponto determinado pelo usuário. O círculo é
desenhado usando preenchimento sólido e, dada a presença do parâmetro
cv::LINE_AA, este será desenhado usando técnicas de antialiasing. Assim,
o círculo terá bordas não serrilhadas, produzindo um efeito visual
agradável na imagem pontilhista.
10.3. Exercícios
-
Utilizando os programas exemplos/canny.cpp e exemplos/pontilhismo.cpp como referência, implemente um programa
cannypoints.cpp. A idéia é usar as bordas produzidas pelo algoritmo de Canny para melhorar a qualidade da imagem pontilhista gerada. A forma como a informação de borda será usada é livre. Entretanto, são apresentadas algumas sugestões de técnicas que poderiam ser utilizadas:-
Desenhar pontos grandes na imagem pontilhista básica;
-
Usar a posição dos pixels de borda encontrados pelo algoritmo de Canny para desenhar pontos nos respectivos locais na imagem gerada.
-
Experimente ir aumentando os limiares do algoritmo de Canny e, para cada novo par de limiares, desenhar círculos cada vez menores nas posições encontradas. A Figura 27 foi desenvolvida usando essa técnica.
-
-
Escolha uma imagem de seu gosto e aplique a técnica que você desenvolveu.
-
Descreva no seu relatório detalhes do procedimento usado para criar sua técnica pontilhista.
11. Quantização vetorial com k-means
Algoritmos de quantização são um grupo de técnicas usadas para mapear os dados presentes em um conjunto grande em um conjunto menor de elementos. É normalmente usada para fins de compressão de dados. Quando um grande conjunto de pontos (vetores) é dividido em em grupos de tamanho menor, diz-se que tem uma quantização vetorial, onde cada grupo é representado por um centróide.
Dos vários algoritmos de quantização vetorial que podem ser encontrados na literatura, o k-means está entre os mais populares. É um algoritmo simples que particiona o espaço N-dimensional em células de Voronoi, onde cada célula é determinada por um centro. O conjunto de todos os pontos no espaço cuja distância para um dado centro é menor que para todos os outros centros define a célula.
O algoritmo k-means funciona conforme os seguintes passos:
-
Escolha \$k\$ como o número de classes para os vetores \$\mathbf{x}_i\$ de \$N\$ amostras, \$i=1,2,\cdots,N\$.
-
Escolha \$\mathbf{m}_1, \mathbf{m}_2,\cdots,\mathbf{m}_k\$ como aproximações iniciais para os centros das classes.
-
Classifique cada amostra \$\mathbf{x}_i\$ usando, por exemplo, um classificador de distância mínima (distância euclideana).
-
Recalcule as médias \$\mathbf{m}_j\$ usando o resultado do passo anterior.
-
Se as novas médias são consistentes (não mudam consideravelmente), finalize o algoritmo. Caso contrário, recalcule os centros e refaça a classificação.
Algo que se percebe do algoritmo k-means é que cada execução leva a um resultado diferente do resultado anterior. Embora o algoritmo normalmente estabilize, algumas execuções podem criar aglomerações melhores que outras. Logo, é comum executar o algoritmo algumas vezes e verificar qual execução gera melhor compactação dos dados. Uma das medidas de compactação - a usada pelo OpenCV - verifica a soma dos quadrados das distâncias dos pontos da amostra para seus respectivos centros.
O programa de referência utilizado para essa tarefa, kmeans.cpp, é mostrado na Listagem Kmeans.
#include <cstdlib>
#include <opencv2/opencv.hpp>
int main(int argc, char** argv) {
int nClusters = 8, nRodadas = 5;
cv::Mat rotulos, centros;
if (argc != 3) {
std::cout << "kmeans entrada.jpg saida.jpg\n";
exit(0);
}
cv::Mat img = cv::imread(argv[1], cv::IMREAD_COLOR);
cv::Mat samples(img.rows * img.cols, 3, CV_32F);
for (int y = 0; y < img.rows; y++) {
for (int x = 0; x < img.cols; x++) {
for (int z = 0; z < 3; z++) {
samples.at<float>(y + x * img.rows, z) = img.at<cv::Vec3b>(y, x)[z];
}
}
}
cv::kmeans(samples, nClusters, rotulos,
cv::TermCriteria(cv::TermCriteria::EPS | cv::TermCriteria::COUNT,
10000, 0.0001),
nRodadas, cv::KMEANS_PP_CENTERS, centros);
cv::Mat rotulada(img.size(), img.type());
for (int y = 0; y < img.rows; y++) {
for (int x = 0; x < img.cols; x++) {
int indice = rotulos.at<int>(y + x * img.rows, 0);
rotulada.at<cv::Vec3b>(y, x)[0] = (uchar)centros.at<float>(indice, 0);
rotulada.at<cv::Vec3b>(y, x)[1] = (uchar)centros.at<float>(indice, 1);
rotulada.at<cv::Vec3b>(y, x)[2] = (uchar)centros.at<float>(indice, 2);
}
}
cv::imshow("kmeans", rotulada);
cv::imwrite(argv[2], rotulada);
cv::waitKey();
}
Para compilar e executar o programa kmeans.cpp, salve-o juntamente com o arquivo Makefile e a imagem sushi.jpg em um diretório e execute a seguinte seqüência de comandos:
$ make kmeans
$ ./kmeans sushi.jpg sushi-kmeans.jpg
A saída do programa kmeans é mostrado na Figura 28
11.1. Descrição do programa kmeans.cpp
O programa kmeans opera sobre a imagem fornecida como primeiro
argumento de modo a reduzir a quantidade de cores presentes na mesma
para um total de 6 cores (que pode ser ajustada pela variável
nClusters).
cv::Mat samples(img.rows * img.cols, 3, CV_32F);
Uma matriz de amostras é criada para armazenar todas as cores dos pixels da imagem. É comum executar o k-means com uma amostra do espaço de entrada, mas utilizou-se a totalidade dos pixels imagem nesse exemplo.
A matriz samples possui um total de linhas igual ao total
de pixels da imagem fornecida e apenas três colunas. Cada coluna é
concebida para armazenar cada uma das componentes de cor (R, G e B)
dos pixels.
samples.at<float>(y + x*img.rows, z) = img.at<cv::Vec3b>(y,x)[z];
A cópia pixel a pixel, componente a componente de cor é realizada da imagem de entrada para a matriz de amostras.
cv::kmeans(samples, nClusters, rotulos,
cv::TermCriteria(cv::TermCriteria::EPS | cv::TermCriteria::COUNT,
10000, 0.0001),
nRodadas, cv::KMEANS_PP_CENTERS, centros);
A matriz com as amostras samples deve conter em cada linha uma das amostras a ser processada pela função disponível pelo opencv. nClusters informa a quantidade de aglomerados que se deseja obter. A matriz rotulos é um objeto do
tipo Mat preenchido com elementos do tipo int, onde cada elemento
identifica a classe à qual pertence a amostra na matriz samples. No
exemplo, um máximo de até 10000 iterações ou tolerância de 0.0001
devem ser atingidos para finalizar o algoritmo. O algoritmo é repetido
por uma quantidade de vezes definida por nRodadas. A rodada que
produz a menor soma de distâncias dos pontos para seus respectivos
centros é escolhida como vencedora. Os centros do algoritmo são
inicializados usando o algoritmo proposto por
Arthur2007. Finalmente,
as coordenadas dos centros são guardadas na matriz centros.
É importante perceber que tanto a matriz de amostras quanto a matriz
com os centros é definida como float para realizar a execução do
algoritmo. As aproximações geradas por matrizes inteiras levariam a
resultados incorretos do k-means.
rotulada.at<cv::Vec3b>(y,x)[0] = (uchar) centros.at<float>(indice, 0);
rotulada.at<cv::Vec3b>(y,x)[1] = (uchar) centros.at<float>(indice, 1);
rotulada.at<cv::Vec3b>(y,x)[2] = (uchar) centros.at<float>(indice, 2);
Por fim, uma versão quantizada da imagem de entrada é composta usando os centros obtidos na execução do k-means.
11.2. Exercícios
-
Utilizando o programa kmeans.cpp como exemplo prepare um programa exemplo onde a execução do código se dê usando o parâmetro
nRodadas=1e inciar os centros de forma aleatória usando o parâmetroKMEANS_RANDOM_CENTERSao invés deKMEANS_PP_CENTERS. Realize 10 rodadas diferentes do algoritmo e compare as imagens produzidas. Explique porque elas podem diferir tanto.
Parte IV: Outras Transformadas Matemáticas
12. Filtragem de forma com morfologia matemática
A filtragem de forma é uma técnica de processamento de imagens que visa corrigir imperfeições relacionadas com a forma de objetos que compõem, como por exemplo pequenas regiões. Ela é realizada através de operações morfológicas que atuam sobre a forma de objetos na imagem, modificando a propriedade dos pixels conforme propriedades de uma vizinhança selecionada. Assim como na operação de convolução a máscara utilizada desempenha um papel fundamental no resultado do processo, na morfologia, o efeito da filtragem é controlada por um conjunto denominado elemento estruturante. O elemento estruturante normalmente é uma matriz binária que define a forma e o tamanho da vizinhança que será utilizada para a filtragem.
A Figura 29 mostra um exemplo típico de uma imagem corrompiada pelo ruído de forma. Perceba que a figura contém várias linhas que não são desejadas, tanto permeando a região de fundo escuro quanto a região branca que representa o objeto.
A filtragem de forma pode ser utilizada para corrigir esse problema usando o programa morfologia.cpp, que é mostrado na Listagem 13.
#include <iostream>
#include <opencv2/opencv.hpp>
int main(int argc, char** argv) {
cv::Mat image, erosao, dilatacao, abertura, fechamento, abertfecha;
cv::Mat str;
if (argc != 2) {
std::cout << "morfologia entrada saida\n";
}
image = cv::imread(argv[1], cv::IMREAD_UNCHANGED);
// image = cv::imread(argv[1], -1);
if(image.empty()) {
std::cout << "Erro ao carregar a imagem: " << argv[1] << std::endl;
return -1;
}
// elemento estruturante
str = cv::getStructuringElement(cv::MORPH_RECT, cv::Size(3, 3));
// erosao
cv::erode(image, erosao, str);
// dilatacao
cv::dilate(image, dilatacao, str);
// abertura
cv::morphologyEx(image, abertura, cv::MORPH_OPEN, str);
// fechamento
cv::morphologyEx(image, fechamento, cv::MORPH_CLOSE, str);
// abertura -> fechamento
cv::morphologyEx(abertura, abertfecha, cv::MORPH_CLOSE, str);
cv::Mat matArray[] = {erosao, dilatacao, abertura, fechamento, abertfecha};
cv::hconcat(matArray, 5, image);
cv::imshow("original", image);
imshow("morfologia", image);
cv::waitKey();
return 0;
}
Para compilar e executar o programa morfologia.cpp, salve-o juntamente com o arquivo Makefile e a imagem morfoobjetos.png em um diretório e execute a seguinte seqüência de comandos:
$ make morfologia
$ ./morfologia morfoobjetos.png
A saída do programa morfologia é mostrado na Figura 30. Da esquerda para a direita são apresentadas as imagens resultantes das operações erosão, dilatação, abertura, fechamento e abertura seguida de fechamento, respectivamente.
12.1. Descrição do programa morfologia.cpp
O programa morfologia.cpp é um exemplo de aplicação da filtragem de forma. Ele recebe como parâmetro de entrada uma imagem e aplica as operações morfológicas de erosão, dilatação, abertura, fechamento e abertura seguida de fechamento. O programa utiliza a biblioteca OpenCV para carregar a imagem e exibir os resultados.
O primeiro passo da filtragem é criar o elemento estruturante que irá modelar as operações de filtragem morfológica.
str = cv::getStructuringElement(cv::MORPH_RECT, cv::Size(3, 3));
A função getStructuringElement() cria um elemento estruturante com a forma de um retângulo de tamanho \$3 \times 3\$, todos preenchidos com o valor 1 (o elemento é representado como um objeto do tipo MAT). Essa marcação 1s indica que o elemento estruturante irá atuar sobre todos os pixels da vizinhança. A função getStructuringElement também pode ser utilizada para criar elementos estruturantes com formas diferentes, como por exemplo um elemento estruturante com forma de cruz, elipse ou disco, preenchido dentro do retângulo que limita o tamanho do elemento.
cv::erode(image, erosao, str);
Realiza a erosão da imagem image pelo elemento estruturante str.
cv::dilate(image, dilatacao, str);
Realiza a dilatação da imagem image pelo elemento estruturante str.
cv::morphologyEx(image, abertura, cv::MORPH_OPEN, str);
Realiza a abertura da imagem image pelo elemento estruturante str. A abertura é uma operação morfológica que consiste na erosão seguida de dilatação. Perceba que, ao contrário da erosão e dilatação, não há uma função específica para realizar a abertura. Para realizar a abertura é necessário chamar a função morphologyEx() passando como parâmetro o valor MORPH_OPEN para o parâmetro op. A função morphologyEx() é uma função genérica que permite realizar algumas das operações morfológicas mais comuns, como erosão, dilatação, abertura, fechamento, top hat, black hat e a transformada hit-or-miss.
cv::morphologyEx(image, fechamento, cv::MORPH_CLOSE, str);
Realiza o fechamento da imagem image pelo elemento estruturante str.
cv::morphologyEx(abertura, abertfecha, cv::MORPH_CLOSE, str);
Realiza a abertura seguida de fechamento da imagem image pelo elemento estruturante str.
12.2. Exercícios
-
Um sistema de captura de imagens precisa realizar o reconhecimento de carateres de um visor de segmentos para uma aplicação industrial. O visor mostra caracteres como estes apresentados na Figura 31.
Ocorre que o software de reconhecimento de padrões apresenta dificuldades de reconhecer os dígitos em virtude da separação existente entre os segmentos do visor. Idealmente, o software deveria reconhecer os dígitos como na Figura 32.
Usando o programa morfologia.cpp como referência, crie um programa que resolva o problema da pré-filtragem de forma para reconhecimento dos caracteres usando operações morfológicas. Você poderá usar as imagens digitos-1.png, digitos-2.png, digitos-3.png, digitos-4.png e digitos-5.png para testar seu programa. Cuidado para deixar o ponto decimal separado dos demais dígitos para evitar um reconhecimento errado do número no visor.